Skip to content

PerturbedEquilibrium - FEATURE - Decompose the plasma energy of free-boundary eigenmodes on the (psi,theta) grid - #465

Open
jaesun57 wants to merge 28 commits into
developfrom
feature/energy-decomposition
Open

jaesun57 wants to merge 28 commits into
developfrom
feature/energy-decomposition

Conversation

@jaesun57

@jaesun57 jaesun57 commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

Release note

  • Audience: users
  • Numerical impact: none (harness @ 59b667d)
  • Migration: none

A new [EnergyDecomposition] section decomposes the plasma potential energy δW_p of selected free-boundary DCON eigenmodes on the (ψ,θ) grid in two forms: the effective-field form ½∫[|b_eff|²/μ₀ − K|ξ_n|²], split into its shear-current, parallel-current and pressure-curvature terms, and the standard form ½∫[|b|²/μ₀ − j·(b×ξ*) + (∇·ξ)*(ξ·∇p)]. It checks (∇×b_eff)·∇ψ = 0, compares each total with eigenmode_plasma_energies, and writes everything to PerturbedEquilibrium/EnergyDecomposition/ in gpec.h5, with eigenmode_index as the coordinate of the eigenmode axis. Runs it cannot handle (multi-n, kinetic, or a non-forward integrator) are warned and skipped.

Regression report

================================================================
Case: diiid_n1 — DIII-D-like equilibrium, n=1, ideal + perturbed equilibrium
================================================================

Regression Report: diiid_n1
================================================================================================================
Ref 1: origin/develop  @ 278057d96 (2026-10-15)
       env: julia 1.11.6, arm64-apple-darwin24.0.0, manifest 3928d757 (pinned), 4 threads/4 BLAS, jit build
Ref 2: feature/energy-decomposition  @ 59b667dc1 (2026-10-16)
       env: julia 1.11.6, arm64-apple-darwin24.0.0, manifest 3928d757 (pinned), 4 threads/4 BLAS, jit build
----------------------------------------------------------------------------------------------------------------
Quantity                                        origin/develop   feature/energy-decomposition  Diff       Status
----------------------------------------------------------------------------------------------------------------
total energy Re(et[1])                          8.008538e-01     8.008538e-01                  0.0e+00    OK
total energy Im(et[1])                          7.920087e-05     7.920087e-05                  0.0e+00    OK
plasma energy Re(ep[1])                         -1.371882e+00    -1.371882e+00                 0.0e+00    OK
vacuum energy Re(ev[1])                         2.172736e+00     2.172736e+00                  0.0e+00    OK
vacuum matrix min eigenvalue                    1.873975e-01     1.873975e-01                  0.0e+00    OK
plasma energy (all)                             [35 elem]        [35 elem]                     0.0e+00    OK
vacuum energy (all)                             [35 elem]        [35 elem]                     0.0e+00    OK
total energy (all)                              [35 elem]        [35 elem]                     0.0e+00    OK
ODE steps (saved)                               1433             1433                          0.0e+00    OK
ODE steps (total)                               1428             1428                          0.0e+00    OK
q0                                              1.204212e+00     1.204212e+00                  0.0e+00    OK
q95                                             4.781723e+00     4.781723e+00                  0.0e+00    OK
beta_t                                          1.327025e-02     1.327025e-02                  0.0e+00    OK
beta_n                                          1.372510e+00     1.372510e+00                  0.0e+00    OK
internal inductance li1                         8.842216e-01     8.842216e-01                  0.0e+00    OK
internal inductance li2                         7.080713e-01     7.080713e-01                  0.0e+00    OK
internal inductance li3                         7.304294e-01     7.304294e-01                  0.0e+00    OK
poloidal beta betap1                            6.680735e-01     6.680735e-01                  0.0e+00    OK
poloidal beta betap2                            5.349831e-01     5.349831e-01                  0.0e+00    OK
poloidal beta betap3                            5.518758e-01     5.518758e-01                  0.0e+00    OK
# singular surfaces                             5                5                             0.0e+00    OK
singular psi locations                          [5 elem]         [5 elem]                      0.0e+00    OK
singular q values                               [5 elem]         [5 elem]                      0.0e+00    OK
current beta betaj                              4.236473e-01     4.236473e-01                  0.0e+00    OK
plasma volume                                   1.829472e+01     1.829472e+01                  0.0e+00    OK
plasma current                                  1.152131e+00     1.152131e+00                  0.0e+00    OK
mpert                                           35               35                            0.0e+00    OK
npert                                           1                1                             0.0e+00    OK
toroidal field bt0                              2.006572e+00     2.006572e+00                  0.0e+00    OK
wall field bwall                                3.880148e-01     3.880148e-01                  0.0e+00    OK
aspect ratio                                    2.845748e+00     2.845748e+00                  0.0e+00    OK
elongation kappa                                1.708323e+00     1.708323e+00                  0.0e+00    OK
q profile (checksum)                            fd2e78791e9f...  fd2e78791e9f...               identical  OK
pressure profile (checksum)                     18025d521b1b...  18025d521b1b...               identical  OK
Mercier D_I profile (checksum)                  ee2331281978...  ee2331281978...               identical  OK
resistive interchange D_R profile (checksum)    718880fe5100...  718880fe5100...               identical  OK
ballooning Delta' profile (checksum)            752fecf6d672...  752fecf6d672...               identical  OK
energy decomposition dW_p (effective field)     N/A              [1 elem]                      N/A        N/A
energy decomposition dW_p (standard form)       N/A              [1 elem]                      N/A        N/A
energy decomposition rel. error vs ep           N/A              [1 elem]                      N/A        N/A
energy decomposition std-form rel. error vs ep  N/A              [1 elem]                      N/A        N/A
energy decomposition curl residual (relative)   N/A              [1 elem]                      N/A        N/A
island half-widths                              [5 elem]         [5 elem]                      0.0e+00    OK
Chirikov parameter                              [5 elem]         [5 elem]                      0.0e+00    OK
||resonant area-weighted field||                5.287276e-04     5.287276e-04                  0.0e+00    OK
dominant-coupling singular values               [3 elem]         [3 elem]                      0.0e+00    OK
|forcing overlap with dominant mode|            1.411416e-04     1.411416e-04                  0.0e+00    OK
|delta_nominal| of coil set 1                   7.033966e-05     7.033966e-05                  0.0e+00    OK
||ddelta/d(shift)|| over coil sets              1.245716e-04     1.245716e-04                  0.0e+00    OK
||ddelta/d(tilt)|| over coil sets               4.176089e-07     4.176089e-07                  0.0e+00    OK
PE plasma energy                                3.320566e+00     3.320566e+00                  0.0e+00    OK
PE vacuum energy                                3.174510e+00     3.174510e+00                  0.0e+00    OK
PE surface energy                               5.507775e+00     5.507775e+00                  0.0e+00    OK
PE toroidal torque                              1.119689e-04     1.119689e-04                  0.0e+00    OK
NTV torque FGAR [N·m]                           5.499361e-01     5.499361e-01                  0.0e+00    OK
NTV kinetic energy dW FGAR [J]                  8.230907e-02     8.230907e-02                  0.0e+00    OK
Runtime (s)                                     135.0s           254.6s                                   --
||forcing b~|| (root-area-weighted)             4.683329e-04     4.683329e-04                  0.0e+00    OK
resonant area-weighted field b^r                [5 elem]         [5 elem]                      0.0e+00    OK
================================================================================================================
Summary: 53 unchanged, 5 missing/N/A


================================================================
Case: solovev_n1 — Solovev analytical equilibrium, n=1, ideal stability
================================================================

Regression Report: solovev_n1
================================================================================================================
Ref 1: origin/develop  @ 278057d96 (2026-10-15)
       env: julia 1.11.6, arm64-apple-darwin24.0.0, manifest 3928d757 (pinned), 4 threads/4 BLAS, jit build
Ref 2: feature/energy-decomposition  @ 59b667dc1 (2026-10-16)
       env: julia 1.11.6, arm64-apple-darwin24.0.0, manifest 3928d757 (pinned), 4 threads/4 BLAS, jit build
----------------------------------------------------------------------------------------------------------------
Quantity                                        origin/develop   feature/energy-decomposition  Diff       Status
----------------------------------------------------------------------------------------------------------------
total energy Re(et[1])                          5.456437e-01     5.456437e-01                  0.0e+00    OK
total energy Im(et[1])                          -1.696939e-05    -1.696939e-05                 0.0e+00    OK
plasma energy Re(ep[1])                         -9.841353e+00    -9.841353e+00                 0.0e+00    OK
vacuum energy Re(ev[1])                         1.038700e+01     1.038700e+01                  0.0e+00    OK
vacuum matrix min eigenvalue                    2.171572e+00     2.171572e+00                  0.0e+00    OK
plasma energy (all)                             [32 elem]        [32 elem]                     0.0e+00    OK
vacuum energy (all)                             [32 elem]        [32 elem]                     0.0e+00    OK
total energy (all)                              [32 elem]        [32 elem]                     0.0e+00    OK
ODE steps (saved)                               413              413                           0.0e+00    OK
ODE steps (total)                               411              411                           0.0e+00    OK
q0                                              1.900006e+00     1.900006e+00                  0.0e+00    OK
q95                                             3.147422e+00     3.147422e+00                  0.0e+00    OK
beta_t                                          4.620629e-02     4.620629e-02                  0.0e+00    OK
beta_n                                          3.215492e+00     3.215492e+00                  0.0e+00    OK
# singular surfaces                             2                2                             0.0e+00    OK
singular psi locations                          [2 elem]         [2 elem]                      0.0e+00    OK
singular q values                               [2 elem]         [2 elem]                      0.0e+00    OK
mpert                                           32               32                            0.0e+00    OK
npert                                           1                1                             0.0e+00    OK
q profile (checksum)                            51fe3fff9691...  51fe3fff9691...               identical  OK
pressure profile (checksum)                     1de4ae32d2b5...  1de4ae32d2b5...               identical  OK
ca_left (checksum)                              43bc63853b76...  43bc63853b76...               identical  OK
energy decomposition dW_p (effective field)     N/A              [1 elem]                      N/A        N/A
energy decomposition dW_p (standard form)       N/A              [1 elem]                      N/A        N/A
energy decomposition rel. error vs ep           N/A              [1 elem]                      N/A        N/A
energy decomposition std-form rel. error vs ep  N/A              [1 elem]                      N/A        N/A
energy decomposition curl residual (relative)   N/A              [1 elem]                      N/A        N/A
Runtime (s)                                     86.0s            169.1s                                   --
================================================================================================================
Summary: 22 unchanged, 5 missing/N/A

Every pre-existing row is unchanged. The five new rows at 59b667d:

case dW_p effective dW_p standard (Re) rel. error eff rel. error std curl residual (relative) ep[1]
solovev_n1 −9.767281 −9.845356 +7.53e-3 −4.07e-4 2.5e-13 −9.841353
diiid_n1 −1.384044 −1.378374 −8.87e-3 −4.73e-3 6.0e-12 −1.371882

Notes for reviewers

This is the Julia port of the Fortran diagnostics in PrincetonUniversity/GPEC#213. Its flags are now effective_field_flag, standard_form_flag, effective_field_terms_flag and curl_check_flag, matching the effective_field_form / standard_form options here. The effective_field_terms_flag split (A, B, C∥, I⊥) is not ported.

Changes since the 2026-10-02 reviews

Review item What changed Commit
§1 Blocking: the stage aborted the run on multi-n or kinetic decks run_energy_decomposition warns and returns for a non-forward integrator, nn_low != nn_high or kinetic matrices; decompose_energy keeps its throws for scripted use 6e252f8
§2 Output location Moved to PerturbedEquilibrium/EnergyDecomposition/; eigenmode_index is the dimension scale of the eigenmode axis and is attached to every total, profile and density 13269f5
§2 Generalizing to fixed-boundary modes and forced responses Not done. The edge condition is wt[:,k] and the reference is ep[k], which exist only for free-boundary DCON eigenmodes; the docs state that scope 13269f5
§3 Geometry shadowing Equilibrium Equilibrium.flux_surface_basis shares the ∇ψ kernel of flux_surface_metric and reads stored nodal derivatives at knots; metric_bsq_interpolant moved to Equilibrium (built once per call); ξ_s comes from compute_node_xi_s!; geometry and the curvature kernel are computed once per surface d8f0d5a
§4 Robustness bsq is a required keyword; zero-extent sentinels keep their declared rank; constructors use fieldcount 6e252f8, d8f0d5a
§5 Cleanup Session history removed from docstrings, ## Fields blocks, named edge tolerance, μ₀j computed once, accumulators driven by name tuples; tests use h5_metadata_check.jl, share results, and the scripting-entry test writes to a scratch copy d8f0d5a, 471f95c
§6 Merge-forward and harness Merged develop (d0ff67f); harness above at 59b667d d0ff67f
Physics §1–§5, §7 Fortran-deviation comments at each call site (metric B², pointwise products, spectral ∂θ, knot grid, reg_spot = 0); decomposition_grid and pointwise-lowering docstrings corrected; long_name of the per-surface profiles reworded; docs say what the curl residual does and does not validate, and name the Fortran counterpart by its flags 3525d5b, 59b667d
Physics §6 Convergence See below 3525d5b, e9518d8, 91bb6d3

Convergence (physics §6)

  • The knot grid is not the cause. Integrating on ODE nodes, or on nodes ∪ knots, does not reduce the mismatch and is worse at mpsi = 128 and at larger mpert. On the knots, the standard-form integrand equals the DCON Lagrangian density to 1e-11, so the standard-form remainder is ψ-quadrature error alone. The knot grid stays, and the docstring no longer says Fortran integrates on the same knots (it integrates its energy check on psifac).

  • The effective-minus-standard offset is equilibrium noise. It comes from the test fixture's loose etol = 1e-7. At mpsi = 128 it falls from 4.9e-3 to 9.7e-5 (mode 1) and from 6.4e-4 to 1.9e-6 (mode 2) when etol = 1e-10. For mode 1, most of it sits in the pressure-curvature term K₃ through ∂ψB²; for mode 2, K₃ does not account for all of it. At etol = 1e-7 every term is rough near the edge.

  • ξ is now evaluated at the knots, not splined from the saved ODE steps. After the develop merge, ForceFreeStates - BUGFIX! - Scale the ODE absolute tolerance to each solution column #480 (per-column ODE absolute tolerance) made the forward sweep save fewer steps, and the splined ξ moved the solovev_n1 standard-form mismatch from 1.7e-5 to 1.6e-3, although ep[1] moved by less than 2e-7. Each knot state is now integrated from the nearest saved step that no singular surface separates from it (ForceFreeStates.solution_state_at). On the Solovev example, the result no longer depends on how densely the sweep saved:

    eulerlagrange_tolerance (saved steps) splined std / eff knot std / eff
    1e-7 (407) −1.45e-3 / +6.48e-3 −4.00e-4 / +7.53e-3
    1e-9 (670) −4.07e-4 / +7.52e-3 −4.04e-4 / +7.53e-3
    1e-11 (1182) −3.98e-4 / +7.53e-3 −3.98e-4 / +7.53e-3

    The splined values converge to the knot values as the saved grid fills in, so the pre-merge −1.7e-5 was a cancellation, not the converged mismatch. The decomposition takes about 0.15 s instead of 0.05 s.

Relative error against ep on the test fixture (mpert = 8, merged code):

mpsi etol mode 1 eff / std mode 2 eff / std
16 1e-7 +1.70e-2 / +1.41e-2 −1.99e-2 / −2.01e-2
128 1e-7 +5.04e-3 / +1.68e-4 +5.64e-4 / −7.8e-5
128 1e-10 +6.4e-5 / +1.61e-4 −4.3e-5 / −4.5e-5

Still open

  • Bernstein (1983), Chance et al. (1992) and Boozer (2004) are not in docs/resources/. Their equation numbers in references.md were checked against the theory note (docs/tex/recon/main.tex in the Fortran repository), not against the papers.
  • The harness ran on a laptop (arm64, 4 threads), not the shared machine.

🤖 Generated with Claude Code

jaesun57 and others added 12 commits September 22, 2026 17:32
…mpute_contra_displacements as a keyword

The eigenmode energy decomposition runs the contravariant convolution unregularized and has no PerturbedEquilibriumControl, so the width becomes a keyword; reconstruct_physical_fields passes ctrl.reg_spot through unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s, result, and surface geometry

First piece of the eigenmode energy decomposition: the [EnergyDecomposition] control struct, the result struct whose fields are the HDF5 leaf names, and the per-surface geometry (gradient and tangent bases, metric, B² derivatives) that every kernel shares instead of re-deriving it as the Fortran does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…gy decomposition

Shear S, curvature projection κ_ψ, parallel-current ratio σ, and the three destabilizing terms K1 = |∇ψ|²σS, K2 = μ₀B²σ², K3 = 2p'κ_ψ per θ point, following the Fortran gpeq_shear/gpeq_curvature/gpeq_K with the periodic spline derivative of the shear geometry term.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nmode and verify its curl has no normal component

eigenmode_modes runs the unregularized gpeq chain for the edge eigenvector wt[:, k] on the equilibrium knots inside the solution domain, splining ξ and ξ' from the ODE grid and recomputing ξ_s from the ideal Euler-Lagrange matrices at each node as the Fortran gpeq_sol does; surface_fields! puts one surface on the θ grid with the band-limited ξ_n of gpeq_normal; effective_field! adds the ξ_n(μ₀ j × n̂) correction of gpeq_c; curl_residual! evaluates (∇×b_eff)·∇ψ in mode space.

The knots matter: between them the metric coefficients, the matrices and the bicubic geometry are interpolated separately and the identity only holds to O(h⁴) (1e-4 on the mpsi = 16 Solovev fixture, O(1) next to a rational surface); on the knots the Solovev test reaches 5e-13.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ndard-form energy densities on a surface

One θ pass per surface produces |b_eff|²/μ₀ with its component split and the three K_i|ξ_n|² terms (Fortran gpeq_epf/gpeq_dst) and, for the standard form, |b|²/μ₀, j·(b×ξ*) and (∇·ξ)*(ξ·∇p) (gpeq_firstform); surface_integral is the periodic Riemann sum the Fortran uses.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…boundary eigenmodes over psi

decompose_energy runs the per-surface kernels threaded over the equilibrium knots of the solution domain, integrates the effective-field and standard-form pieces in ψ with a cubic spline, scales them by 2μ₀/ψ₀² to the normalization of eigenmode_plasma_energies, and stores the two consistency checks (curl residual over the knots, mismatch against ep[k]) as data.

The per-surface fields now form Jξ^ψ, the covariant b_i and ξ_n pointwise from the exact local metric instead of the mode-truncated gpeq_contra/gpeq_cova/gpeq_normal arrays: with the truncated arrays the |V|² and K₂|ξ_n|² pieces no longer cancel and the effective-field total sat 21 % below ep[1] on the Solovev fixture independent of the ψ grid. Pointwise, both forms converge with the grid: relative mismatch against ep at mpsi = 16/48/128 is 1.4e-2/3.4e-3/2.3e-3 (effective) and 1.2e-2/1.4e-3/2.5e-4 (standard) for the least-stable mode; the knot curl residual stays at 1e-13.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rceFreeStates/EnergyDecomposition

The eigenmode δW_p decomposition sits beside FreeBoundaryStability, with the profiles on the decomposition ψ scale, the optional Densities/ subgroup on a θ scale, zero-extent datasets for a disabled form, and a full long_name/units/dims table so the schema test holds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…he [EnergyDecomposition] section

The stage runs right after the ForceFreeStates output is written, so it is available with force_termination = true and without any forcing definition; it warns and skips when the solve has no dense ξ solution or no free-boundary eigenmodes, and main returns it as energy. energy_decomposition is the scripting entry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ev and DIII-D-like ideal decks

Both decks integrate forward with vac_flag, so the eigenmode energy decomposition runs and its group is produced for the regression cases built on them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…in solovev_n1 and diiid_n1

The two δW_p route totals, their relative mismatch against eigenmode_plasma_energies, and the relative (∇×b_eff)·∇ψ residual of the least-stable eigenmode.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nmodes

A short page with the two forms of δW_p, the K kernel, the normalization against eigenmode_plasma_energies, the TOML section, the output group and the API, plus the schema and architecture entries. Docstring cross-references of the unexported helpers become plain code spans since they are never rendered.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ngth in the energy-decomposition kernel

σ, K₂ and κ_ψ now take B² = (χ'/J)²(g_θθ + 2q g_θζ + q²g_ζζ) from a bicubic of the straight-field-line metric on the equilibrium grid instead of the equilibrium's separate |B| spline, which differs from it by up to several percent near the edge of the Solovev example; the difference broke the |V|² ↔ K₂|ξ_n|² cancellation of the effective-field form. Solovev mpsi = 128: effective-form mismatch against ep[1] 1.08e-2 → 7.9e-3, standard form unchanged at 1.7e-5; mpsi = 256: 9.9e-4 and 8.8e-3.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions github-actions Bot added the feature New capability label Sep 22, 2026
@jaesun57 jaesun57 closed this Sep 22, 2026
@jaesun57
jaesun57 deleted the feature/energy-decomposition branch September 22, 2026 23:40
@jaesun57
jaesun57 restored the feature/energy-decomposition branch September 22, 2026 23:47
…ed field as δB in the energy-decomposition page

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@jaesun57 jaesun57 reopened this Sep 23, 2026
@jaesun57 jaesun57 self-assigned this Sep 23, 2026
jaesun57 and others added 4 commits September 24, 2026 13:07
…and the energy decomposition over through gpec.h5

The [EnergyDecomposition] stage still runs inside main after the force-free-states solve, but its result no longer rides on the returned NamedTuple: it is written to ForceFreeStates/EnergyDecomposition/ in gpec.h5, and scripts get it from energy_decomposition(result.ffs), which is now exported next to solve and perturbed_equilibrium. This matches how the LocalStability stage hands its result over and keeps main's (; ffs, pe, slayer) shape that runtests_result_struct.jl pins.

Also restore develop's text where the earlier formatter run had reflowed unrelated lines in GeneralizedPerturbedEquilibrium.jl and FieldReconstruction.jl, and make the main-stage test read the decomposition back from gpec.h5.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…oolkit/GPEC into feature/energy-decomposition
…and exports clear of the resonant-coupling lines

develop's ResonantCoupling additions land on the same lines the branch used for its include and exports in PerturbedEquilibrium.jl and for its key-file bullet in architecture.md. Put the branch's lines where develop did not touch (after the FieldReconstruction include, in the type-export block, after the Response.jl bullet) so the branch merges cleanly without a merge commit. EnergyDecomposition.jl needs nothing from Response, SingularCoupling or Utils at parse time.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@jaesun57
jaesun57 marked this pull request as ready for review September 24, 2026 17:40
@jaesun57
jaesun57 requested review from logan-nc and a lite review from Copilot September 24, 2026 17:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The review found one critical integration issue and two moderate correctness/schema issues.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity · 1 Medium severity

Open (2)
What changed in this PR

Adds an optional energy-decomposition diagnostic for free-boundary eigenmodes, with effective-field and standard-form calculations, HDF5 output, tests, regression tracking, and documentation.

Changes:

  • Adds decomposition APIs and pipeline/TOML integration.
  • Adds regression quantities and example configuration.
  • Adds tests, documentation, and HDF5 schema guidance.
  • Refactors displacement regularization to a keyword argument.

Unresolved findings: critical integration gating issue (4 votes), moderate disabled-field HDF5 shape issue (3 votes), and moderate truncated-domain integration issue (1 vote).

File Description
test/​runtests.jl Registers energy-decomposition tests.
test/​runtests_energy_decomposition.jl Tests calculations, toggles, metadata, and output.
src/​PerturbedEquilibrium/​PerturbedEquilibrium.jl Includes and exports the new APIs.
src/​PerturbedEquilibrium/​FieldReconstruction.jl Refactors regularization argument handling.
src/​PerturbedEquilibrium/​EnergyDecomposition.jl Implements decomposition, diagnostics, and HDF5 writing.
src/​GeneralizedPerturbedEquilibrium.jl Integrates the optional stage and scripting API.
regression-harness/​cases/​solovev_n1.toml Tracks decomposition diagnostics.
regression-harness/​cases/​diiid_n1.toml Tracks decomposition diagnostics.
examples/​Solovev_ideal_example/​gpec.toml Enables the feature in the example.
examples/​DIIID-like_ideal_example/​gpec.toml Enables the feature in the example.
docs/​src/​energy_decomposition.md Documents theory, usage, outputs, and API.
docs/​src/​citations.md Adds supporting citations.
docs/​src/​api.md Documents the scripting entry point.
docs/​make.jl Adds the documentation page to navigation.
docs/​development/​references.md Records implementation references.
docs/​development/​hdf5-conventions.md Documents the new output schema.
docs/​development/​architecture.md Updates the architecture overview.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +863 to +864
ForceFreeStates.require_solution(result, "energy decomposition") || return nothing
ForceFreeStates.require(result, :free_boundary, "energy decomposition") || return nothing
:effective_b_curl_residual_density, :b_squared_density, :current_coupling_density, :pressure_compression_density)

# Empty result arrays are written zero-extent (the file's not-computed sentinel).
_h5_value(x::AbstractArray) = isempty(x) ? similar(x, 0) : x

@logan-nc logan-nc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consolidated review from three independent passes (structural, performance, code clarity), each claim checked against the source. Ordered by what I'd want settled before merge. The physics itself was not reviewed here — see the end.

1. Blocking: the stage aborts the run instead of skipping

run_energy_decomposition (GeneralizedPerturbedEquilibrium.jl, new hunk) gates only require_solution and require(:free_boundary), which warn and return. But decompose_energy throws ArgumentError on nlow != nhigh and mats.kinetic !== nothing (EnergyDecomposition.jl:698–699). A legal multi-n or kinetic deck that carries [EnergyDecomposition] therefore kills the whole run after FFS — before PE, KineticForces or SLAYER — contradicting the stage docstring ("warned and skipped, like every optional stage") and docs/src/energy_decomposition.md. Copilot flagged the same gap. ~10 lines: warn-and-return for both conditions in the driver, keep the throws for scripted use.

2. Decide before merge: where the output lives

  • The group ForceFreeStates/EnergyDecomposition/ bakes "free-boundary eigenmodes only" into the schema, while the producer is a PE-module stage. Free-boundary enters the code through just wt[:,k] (:342) and ep[k] (:814); a decompose_energy(ffs, edge::Vector; reference=nothing) entry would serve fixed-boundary modes and forced responses at ~half a day, and PerturbedEquilibrium/… would then be the natural home. HDF5 renames are clean breaks here with no shim, so the placement is worth deciding now even if the generalization waits.
  • eigenmode_index = copy(eigenmodes) (:719) echoes a control value into an output group (hdf5-conventions.md: never echo control-struct values outside Input/). It is defensible as the coordinate of the eigenmode dimension — in which case attach it as a dimension scale; only psi is attached today (:926–927).

3. Structural: the geometry block shadows Equilibrium

  • surface_geometry! (:160–166) reproduces Equilibrium.flux_surface_metric (FluxSurfaceMetrics.jl:16–23) line for line — the function whose docstring declares it "the single source of truth for the |∇ψ| computation reused across the FFS and PE modules" — then extends it with w21/w22/w31/w32 and the tangent basis. Extend flux_surface_metric (or add a sibling in Equilibrium) and call it, rather than carrying a second implementation in PE.
  • The same block re-interpolates geometry with 13 bicubic evaluations per point at the equilibrium knots, where rzphi_*.nodal_derivs.partials[1:3, i, j] already holds f, ∂f/∂ψ, ∂f/∂θ and make_metric (Fourfit.jl:193–197) and GeometryProfiles.jl:44 read them directly. Only the appended edge node needs the interpolant. The feature is cheap overall (order 0.1–1 s), so this is about consistency with the existing pattern more than speed.
  • metric_bsq_interpolant (:213–230) is a private, persistent B² that competes with equil.eqfun_B and is rebuilt on every call (:717). Whether metric-consistent B² is the right physics is for the physics review; either way its owner should be Equilibrium or make_metric, so a future consumer (LocalStability shear, KineticForces) does not grow a third B².
  • eigenmode_modes (:361–367) re-encodes −A⁻¹(Bξ′ + Cξ) by hand; compute_node_xi_s! (ForceFreeStates/EulerLagrange.jl:1397, used at Result.jl:174) already does exactly that.
  • surface_geometry! and curvature_kernel! depend only on (equil, psi) but sit inside the eigenmode loop (:755–770): all geometry is computed twice for eigenmodes = [1, 2]. Hoist per surface.

4. Robustness

  • surface_geometry!(…; bsq=nothing) leaves geom.bsq zero, and curvature_kernel! divides by it (:285). Make bsq required.
  • similar(x, 0) (:921–922) collapses disabled 2-D/3-D outputs to rank-1 while the metadata table declares (psi, eigenmode) / (psi, theta, eigenmode) — the writer cannot attach the declared dims (Copilot). The zero-extent sentinel has precedent, but only if the rank is kept.
  • Single-n is load-bearing, not just declared: N = ffs.numpert_total (:349) is written into xi_s[i, :] of width mpert (:367), a DimensionMismatch the guard at :698 is what prevents.
  • Constructors hard-code field counts (for _ in 1:16, 1:6, 1:13, 1:6, 1:8 at :131/248/400/451/539); fieldcount(T) stops them rotting when a field is added.

5. Cleanup

  • Code comments are clean — no run exceeds two lines. The narration is in docstrings: :208–211 and :409–412 carry session history (keep the one-sentence reason; drop the rest), and test:176, :208 record observed percentages that belong in the docs, not a comment.
  • docs/src/energy_decomposition.md:64 says "better than 1 %"; the tests record 1.4 %, 1.2 % and 3.3 % at the mpsi = 16 fixture (test:176, :208). "≲1–3 % at mpsi = 16, converging with resolution" matches the evidence.
  • test/runtests_energy_decomposition.jl (292 lines) would be ~200 with the same coverage: _ed_metadata_violations (:216–231) duplicates test/h5_metadata_check.jl — include it; :32–50 tests the reg_spot keyword, not this kernel; :70–73 asserts kwdef defaults; :86–90 re-derive K1/K2/K3 from :293–295 (tautological); :99–128 hand-plumbs the curl residual that :173 already pins; decompose_energy runs eight times — hoist the shared results. :287–291 writes into the shared fixture's gpec.h5 and only works because it is the last testset — write to a temp copy.
  • Five structs describe their fields in prose rather than a ## Fields block (:104, :232, :376, :435, :516); the unnamed 1e-10 at :315; μ₀j^θ and μ₀j^ζ computed three times (:286, :475, :572); the 13 hand-named accumulators plus three mirrored copy blocks (:737–749, :796–837) could be driven from one name tuple, as _allocate_result! already half-does.

6. Before merge

  • The branch is ~250 commits behind and develop has since rewritten FieldReconstruction.jl (225 lines) and the driver (+455); your 6-line reg_spot change and 47-line hook land on rewritten code. DIRTY here is semantic, not textual (zero conflict markers) — worth re-reading both hunks after the merge-forward.
  • The regression report was a laptop run (arm64, 4 threads) against a develop that has moved; please re-run on the shared machine after merging forward and refresh the stamp.
  • This review deliberately did not cover the physics (metric-B² vs eqfun_B, the pointwise covariant lowering your docstring at :408–412 justifies, the ξ_s recomputation at knots). Those deserve a physics pass and I'd ask for one before approval.

Credit where due: Copilot's two inline comments — the gating gap and the similar(x, 0) rank collapse — were both real and are folded in above.

🤖 Generated with Claude Code

https://claude.ai/code/session_0186CLfjeJeSusNTva5RMfGU

@logan-nc logan-nc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Physics pass, to close the gap my earlier review left open. Checked term by term against the Fortran counterpart and the theory note on origin/recon of the Fortran repository (gpout_recon/gpout_recon2 in gpout.f, gpeq_c/gpeq_K/gpeq_shear/gpeq_curvature/gpeq_cveri/gpeq_firstform in gpeq.f, docs/tex/recon/main.tex) — not on Fortran develop, which is worth stating in the docs since "fidelity to Fortran" means fidelity to that branch. Every claim marked verified was read or derived; the Fortran line references were checked by me as well.

Verdict: correct with caveats. No missing terms, no placeholders; every kernel either matches origin/recon line for line or is a defensible, more accurate deviation. One thing should be settled before merge — see §6 — because it is the untested explanation for the 1.4–3.3 % mismatch.

1. Both δW forms — verified

  • Standard form (:565–585): derived from −½∫ξ*·F(ξ) with the full ξ (the ξ∥ pieces of the J×Q and ∇·ξ terms cancel, so using the full Jξ^θ, Jξ^ζ is right). det_term at :572–575 is J·det[j, b, ξ*] with j^ψ = 0; ∇·ξ at :577 is (1/J)[∂ψ(Jξ^ψ) + ∂θ(Jξ^θ) − 2πin Jξ^ζ] in the exp(2πi(mθ−nζ)) convention. Term for term identical to gpeq_firstform. One divergence: ∂θ(Jξ^θ) is spectral here (:415–418) and a periodic spline there — fine, but annotate it as a Fortran deviation.
  • Effective field b_eff = b + ξ_n μ₀ j×n̂ (:455–480): all six component formulas verified by hand and identical to gpeq_c (cwt/cwz/cvp/cvt/cvz). μ₀j^θ = −F′/J and μ₀j^ζ = −(μ₀p)′/χ′ − qF′/J follow from ∇×B and (j×B)·∇ψ = p′, given F_deriv = d(2πF)/dψ_N (inferred from the Fortran sq%f1(1) usage; please confirm).
  • K = K1 + K2 + K3 (:293–295): identical to gpeq_K and main.tex; units consistent (K|ξ_n|² is Pa); K3 = 2p′κ_ψ checks against the interchange term. Caveat: K1, K2 and the completing-the-square identity were verified against main.tex and gpeq_K only — Bernstein 1983, Chance 1992 and Boozer 2004 are not in docs/resources/, so the equation numbers this PR adds to references.md/citations.md are unverified. Please either add the PDFs or cite the theory note.
  • ½, Re, conjugation, normalization: Re[(Jc^i)* c_i]/(μ₀J) is Hermitian-correct; the ½ matches Fortran dw_raw = 0.5*total; the 2μ₀/ψ₀² scale (:602) with unit-power-normalized wt[:,k] as edge BC (:342) is the same convention as Fortran's dcon_fac after idcon.f undoes its rescaling; ep = diag(wt†·wp·wt) with wp = (u2/u1)/ψ₀² matches free.f. No vacuum or surface term belongs in the comparison — ep is the plasma boundary term Ξ†(FΞ′+KΞ), i.e. the volume δW_p; ev is separate. Verified in concept; the DCON ½/μ₀ scale was not re-derived from Glasser 2016, but the 1e-3 agreement rules out a missing O(1) factor.

2. Metric-B² vs eqfun_B — your choice is right; say so at the call site

Fortran gpeq_K and gpeq_curvature take B² from eqfun (bsq_val = eqfun%f(1)**2, with fx/fy for its derivatives). make_metric builds only g_ij/J, J, J_ψ — no eqfun — so the Euler–Lagrange operator and the b reconstruction carry the metric B², and the C∥ = K2|ξ_n|² cancellation holds only with that B². Using it is operator-consistent and an improvement over Fortran. The ~1 % is the mismatch between the equilibrium's direct |B| and the inverse-map metric at mpsi = 16 — coordinate-transformation accuracy converging with equilibrium resolution, not hidden physics. It needs the project's one-line deviation note at :285, :291, :294, not only in the docstring.

3. Pointwise covariant lowering — the claim holds, with two corrections

gpeq_c back-transforms band-limited modes before squaring, so ∮|C|² and ∮K2|ξ_n|² each lose out-of-band energy that does not cancel between them; pointwise products on the mtheta grid are Parseval-exact (aliasing aside). So the Julia effective-field numbers will differ from Fortran recon reference values at the few-% level, and Julia is the more accurate. Two corrections to the docstring at :408–412: "resolution-independent" should read "ψ-resolution-independent, mpert-dependent" (the deficit shrinks with the band); and this is a hybrid — Jξ^ψ is formed pointwise (:416, :426) but Jξ^θ, Jξ^ζ still come from the truncated jmat convolution in compute_contra_displacements — the docstring implies all-pointwise.

4. ξ_s at the knots — verified, no physical content in knot-vs-node

−A⁻¹(Bξ′ + Cξ) (:361–367) matches Fortran gpeq_sol and Julia's own compute_node_xi_s! (EulerLagrange.jl:1397, same raw A; Cholesky vs LDLᵀ immaterial). It is the algebraic Euler–Lagrange relation for Ξ_s, so evaluating at knots rather than ODE nodes changes only the interpolation accuracy of (ξ, ξ′), nothing physical. That argues for calling the existing helper rather than re-encoding it (as my earlier review noted).

5. The curl residual — right check, state its scope

(:495–515) is identical to gpeq_cveri and is the Ξ_s equation itself (∇ψ·∇×C = 0 ⇔ δj·∇ψ = 0 closure), so at the knots it holds by construction. It validates the ξ_s algebra, the g22/g23/g33 lowering and the b_θ, b_ζ assembly against A, B, C — not the ψ-ODE and not b_ψ or g11/g12/g13/|∇ψ|². A roundoff-level value says nothing about the eigenmode's correctness. Worth one sentence in the docs so it isn't read as a global check.

6. Convergence — the one thing to settle before merge

Expected: ~h⁴ for the cubic-spline ψ-quadrature on a smooth integrand, spectral in θ. Observed from the PR's own numbers (mpsi 16 → 128): effective form ~h^0.9, standard form ~h^1.8. That is not quadrature-order behaviour.

The leading explanation is a mis-documented Fortran deviation. gpout.f says verbatim, just above the energy check: "Perform the energy check on the DCON solution grid. Do not use rzphi%xs for this comparison; it is a coarse geometry grid." — and integrates on psifac. decomposition_grid (:309–316) integrates on the rzphi_xs equilibrium knots, and the docstring at :305 says gpout_recon "loops over the same knots" — true of its diagnostics loop, not of its energy integral. With the 1/(m − nq) structure in ξ_s and Jξ^θ, the coarse-knot integral is the natural suspect for both the size of the mismatch and the sub-quadrature rate; the slower effective-form rate is consistent with K depending on spline derivatives (bsq_psi, dratio) that lose an order. A confound: ep itself moves with mpsi.

Discriminating test, cheap to run: fix mpsi, integrate on ODE nodes ∪ knots instead of knots alone, and report how the relative error changes. If it collapses, fix the grid and the docstring; a separate mpert scan would then bound the band-truncation floor at mpsi = 128.

7. Fortran correspondence and the annotations owed

gpout_recon/gpeq_c/gpeq_K/gpeq_shear/gpeq_curvature/gpeq_cveri/gpeq_epf/gpeq_dst/gpeq_firstform ↔ decompose_energy/effective_field!/curvature_kernel!/curl_residual!/surface_densities!. Term-level divergences, each needing the project's one-line deviation comment at the call site: (a) metric B² vs eqfun B²; (b) pointwise vs band-truncated C_i, ξ_n; (c) spectral vs spline ∂θ; (d) radial grid knots vs psifac; (e) reg_spot = 0 vs Fortran's regularized xmp1/xmt path in gpeq_firstform (inferred from variable names, not traced). Also: the long_name on current_coupling and pressure_compression (:940–941) says "imaginary part is roundoff" — true of the volume totals, not of the per-surface profiles; reword.

Not checked: the Bernstein/Chance/Boozer equation numbers; the DCON ½/μ₀ convention from Glasser 2016 directly; the edge amplitude in sum_eigenmode_contributions; gpeq_firstform's regularization path; gpout_recon2's radial grid; aliasing error of the mtheta Riemann sum.

🤖 Generated with Claude Code

https://claude.ai/code/session_0186CLfjeJeSusNTva5RMfGU

jaesun57 and others added 9 commits October 15, 2026 14:28
…warning on multi-n, kinetic and non-forward runs

run_energy_decomposition now warns and returns nothing for a non-forward integrator, nn_low != nn_high or kinetic matrices instead of letting decompose_energy throw and abort main. Disabled forms are written as their zero-extent arrays directly, so the sentinels keep the rank their dims attribute declares.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…c B² and node ξ_s with Equilibrium and ForceFreeStates in the energy decomposition

Add Equilibrium.flux_surface_basis (interpolant and knot methods, sharing the ∇ψ kernel of flux_surface_metric) and move metric_bsq_interpolant next to it. surface_geometry! takes bsq as a required keyword and reads the stored knot values on equilibrium knots. eigenmode_modes calls ForceFreeStates.compute_node_xi_s!, which now also accepts a single displacement vector. Geometry, the curvature kernel and μ₀j are computed once per surface before the eigenmode loop, the per-surface integrals and density copies are driven by the profile name tuples, the scratch constructors use fieldcount, and the structs document their fields. Decomposition totals are unchanged to 1e-15 on the Solovev fixture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…iations from the Fortran recon branch

Mark at each call site where the decomposition departs from the Fortran recon branch (metric B², pointwise products, spectral θ-derivative, knot-grid integration, reg_spot = 0). Correct the decomposition_grid and surface_fields! docstrings and the current_coupling and pressure_compression long_names, and state in the docs what the curl residual validates and the measured convergence, including the open edge offset of the effective-field form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and drop redundant tests

Use the shared h5_metadata_check walker, compute the decompositions once, drop tests that re-derived definitions or duplicated the pinned curl residual, and add checks for the skip warnings, the zero-extent ranks, knot versus interpolant geometry and the required bsq keyword. The scripting-entry test now writes to a scratch copy of gpec.h5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…bedEquilibrium/EnergyDecomposition with an eigenmode_index coordinate

The decomposition of free-boundary DCON eigenmodes now writes to PerturbedEquilibrium/EnergyDecomposition/ instead of ForceFreeStates/EnergyDecomposition/, with no legacy path. eigenmode_index is marked as the dimension scale of the eigenmode axis and attached to every total, profile and density, so it reads as a coordinate rather than an echoed control value. The writer, tests, docs, schema table and the solovev_n1 and diiid_n1 harness cases move together, and the docs state that the stage covers free-boundary eigenmodes only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…omposition

# Conflicts:
#	docs/development/references.md
#	docs/make.jl
#	regression-harness/cases/diiid_n1.toml
#	src/GeneralizedPerturbedEquilibrium.jl
…uracy and attribute the form offset to the equilibrium tolerance

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s instead of splining the saved ODE steps

Each decomposition knot now gets its Euler-Lagrange state by integrating from the nearest saved step that no singular surface separates from it (ForceFreeStates.solution_state_at), so ξ and ξ' no longer depend on how densely the forward sweep saved. On the Solovev example the standard-form mismatch is -4.0e-4 at every eulerlagrange_tolerance from 1e-7 to 1e-11, where the splined value moved from -1.45e-3 to -4.0e-4. Pre-existing harness rows are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gs instead of a branch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jaesun57
jaesun57 requested a review from logan-nc October 16, 2026 21:15

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New capability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants